/*
 * Copyright (c) 2015-present, Parse, LLC.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 */
package com.parse;

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.text.TextUtils;
import android.util.Pair;
import com.parse.OfflineQueryLogic.ConstraintMatcher;
import com.parse.boltsinternal.Capture;
import com.parse.boltsinternal.Continuation;
import com.parse.boltsinternal.Task;
import com.parse.boltsinternal.TaskCompletionSource;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.WeakHashMap;
import org.json.JSONException;
import org.json.JSONObject;

class OfflineStore {

    /** SQLite has a max of 999 SQL variables in a single statement. */
    private static final int MAX_SQL_VARIABLES = 999;
    // Lock for all members of the store.
    private final Object lock = new Object();
    // Helper for accessing the database.
    private final OfflineSQLiteOpenHelper helper;
    /**
     * In-memory map of UUID -> ParseObject. This is used so that we can always return the same
     * instance for a given object. The only objects in this map are ones that are in the database.
     */
    private final WeakValueHashMap<String, ParseObject> uuidToObjectMap = new WeakValueHashMap<>();
    /**
     * In-memory map of ParseObject -> UUID. This is used to that when we see an unsaved ParseObject
     * that's already in the database, we can update the same record in the database. It stores a
     * Task instead of the String, because one thread may want to reserve the spot. Once the task is
     * finished, there will be a row for this UUID in the database.
     */
    private final WeakHashMap<ParseObject, Task<String>> objectToUuidMap = new WeakHashMap<>();
    /**
     * In-memory set of ParseObjects that have been fetched from the local database already. If the
     * object is in the map, a fetch of it has been started. If the value is a finished task, then
     * the fetch was completed.
     */
    private final WeakHashMap<ParseObject, Task<ParseObject>> fetchedObjects = new WeakHashMap<>();
    /**
     * In-memory map of (className, objectId) -> ParseObject. This is used so that we can always
     * return the same instance for a given object. Objects in this map may or may not be in the
     * database.
     */
    private final WeakValueHashMap<Pair<String, String>, ParseObject>
            classNameAndObjectIdToObjectMap = new WeakValueHashMap<>();

    /** Used by the static method to create the singleton. */
    /* package */ OfflineStore(Context context) {
        this(new OfflineSQLiteOpenHelper(context));
    }

    /* package */ OfflineStore(OfflineSQLiteOpenHelper helper) {
        this.helper = helper;
    }

    /**
     * Gets the UUID for the given object, if it has one. Otherwise, creates a new UUID for the
     * object and adds a new row to the database for the object with no data.
     */
    private Task<String> getOrCreateUUIDAsync(final ParseObject object, ParseSQLiteDatabase db) {
        final String newUUID = UUID.randomUUID().toString();
        final TaskCompletionSource<String> tcs = new TaskCompletionSource<>();

        synchronized (lock) {
            Task<String> uuidTask = objectToUuidMap.get(object);
            if (uuidTask != null) {
                return uuidTask;
            }

            // The object doesn't have a UUID yet, so we're gonna have to make one.
            objectToUuidMap.put(object, tcs.getTask());
            uuidToObjectMap.put(newUUID, object);
            fetchedObjects.put(object, tcs.getTask().onSuccess(task -> object));
        }

        /*
         * We need to put a placeholder row in the database so that later on, the save can just be an
         * update. This could be a pointer to an object that itself never gets saved offline, in which
         * case the consumer will just have to deal with that.
         */
        ContentValues values = new ContentValues();
        values.put(OfflineSQLiteOpenHelper.KEY_UUID, newUUID);
        values.put(OfflineSQLiteOpenHelper.KEY_CLASS_NAME, object.getClassName());
        db.insertOrThrowAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, values)
                .continueWith(
                        (Continuation<Void, Void>)
                                task -> {
                                    // This will signal that the UUID does represent a row in the
                                    // database.
                                    tcs.setResult(newUUID);
                                    return null;
                                });

        return tcs.getTask();
    }

    /**
     * Gets an unfetched pointer to an object in the db, based on its uuid. The object may or may
     * not be in memory, but it must be in the database. If it is already in memory, that instance
     * will be returned. Since this is only for creating pointers to objects that are referenced by
     * other objects in the data store, that's a fair assumption.
     *
     * @param uuid The object to retrieve.
     * @param db The database instance to retrieve from.
     * @return The object with that UUID.
     */
    private <T extends ParseObject> Task<T> getPointerAsync(
            final String uuid, ParseSQLiteDatabase db) {
        synchronized (lock) {
            @SuppressWarnings("unchecked")
            T existing = (T) uuidToObjectMap.get(uuid);
            if (existing != null) {
                return Task.forResult(existing);
            }
        }

        /*
         * We want to just return the pointer, but we have to look in the database to know if there's
         * something with this classname and object id already.
         */

        String[] select = {
            OfflineSQLiteOpenHelper.KEY_CLASS_NAME, OfflineSQLiteOpenHelper.KEY_OBJECT_ID
        };
        String where = OfflineSQLiteOpenHelper.KEY_UUID + " = ?";
        String[] args = {uuid};
        return db.queryAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, select, where, args)
                .onSuccess(
                        task -> {
                            Cursor cursor = task.getResult();
                            cursor.moveToFirst();
                            if (cursor.isAfterLast()) {
                                cursor.close();
                                throw new IllegalStateException(
                                        "Attempted to find non-existent uuid " + uuid);
                            }

                            synchronized (lock) {
                                // We need to check again since another task might have come around
                                // and added it to
                                // the map.
                                // TODO (grantland): Maybe we should insert a Task that is resolved
                                // when the query
                                // completes like we do in getOrCreateUUIDAsync?
                                @SuppressWarnings("unchecked")
                                T existing = (T) uuidToObjectMap.get(uuid);
                                if (existing != null) {
                                    return existing;
                                }

                                String className = cursor.getString(0);
                                String objectId = cursor.getString(1);
                                cursor.close();
                                @SuppressWarnings("unchecked")
                                T pointer = (T) ParseObject.createWithoutData(className, objectId);
                                /*
                                 * If it doesn't have an objectId, we don't really need the UUID, and this simplifies
                                 * some other logic elsewhere if we only update the map for new objects.
                                 */
                                if (objectId == null) {
                                    uuidToObjectMap.put(uuid, pointer);
                                    objectToUuidMap.put(pointer, Task.forResult(uuid));
                                }
                                return pointer;
                            }
                        });
    }

    /**
     * Runs a ParseQuery against the store's contents.
     *
     * @return The objects that match the query's constraints.
     */
    /* package for OfflineQueryLogic */ <T extends ParseObject> Task<List<T>> findAsync(
            ParseQuery.State<T> query, ParseUser user, ParsePin pin, ParseSQLiteDatabase db) {
        return findAsync(query, user, pin, false, db);
    }

    /**
     * Runs a ParseQuery against the store's contents. May cause any instances of T to get fetched
     * from the offline database. TODO(klimt): Should the query consider objects that are in memory,
     * but not in the offline store?
     *
     * @param query The query.
     * @param user The user making the query.
     * @param pin (Optional) The pin we are querying across. If null, all pins.
     * @param isCount True if we are doing a count.
     * @param db The SQLiteDatabase.
     * @param <T> Subclass of ParseObject.
     * @return The objects that match the query's constraints.
     */
    private <T extends ParseObject> Task<List<T>> findAsync(
            final ParseQuery.State<T> query,
            final ParseUser user,
            final ParsePin pin,
            final boolean isCount,
            final ParseSQLiteDatabase db) {
        /*
         * This is currently unused, but is here to allow future querying across objects that are in the
         * process of being deleted eventually.
         */
        final boolean includeIsDeletingEventually = false;

        final OfflineQueryLogic queryLogic = new OfflineQueryLogic(this);

        final List<T> results = new ArrayList<>();

        Task<Cursor> queryTask;
        if (pin == null) {
            String table = OfflineSQLiteOpenHelper.TABLE_OBJECTS;
            String[] select = {OfflineSQLiteOpenHelper.KEY_UUID};
            String where = OfflineSQLiteOpenHelper.KEY_CLASS_NAME + "=?";
            if (!includeIsDeletingEventually) {
                where += " AND " + OfflineSQLiteOpenHelper.KEY_IS_DELETING_EVENTUALLY + "=0";
            }
            String[] args = {query.className()};

            queryTask = db.queryAsync(table, select, where, args);
        } else {
            Task<String> uuidTask = objectToUuidMap.get(pin);
            if (uuidTask == null) {
                // Pin was never saved locally, therefore there won't be any results.
                return Task.forResult(results);
            }

            queryTask =
                    uuidTask.onSuccessTask(
                            task -> {
                                String uuid = task.getResult();

                                String table =
                                        OfflineSQLiteOpenHelper.TABLE_OBJECTS
                                                + " A "
                                                + " INNER JOIN "
                                                + OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES
                                                + " B "
                                                + " ON A."
                                                + OfflineSQLiteOpenHelper.KEY_UUID
                                                + "=B."
                                                + OfflineSQLiteOpenHelper.KEY_UUID;
                                String[] select = {"A." + OfflineSQLiteOpenHelper.KEY_UUID};
                                String where =
                                        OfflineSQLiteOpenHelper.KEY_CLASS_NAME
                                                + "=?"
                                                + " AND "
                                                + OfflineSQLiteOpenHelper.KEY_KEY
                                                + "=?";
                                if (!includeIsDeletingEventually) {
                                    where +=
                                            " AND "
                                                    + OfflineSQLiteOpenHelper
                                                            .KEY_IS_DELETING_EVENTUALLY
                                                    + "=0";
                                }
                                String[] args = {query.className(), uuid};

                                return db.queryAsync(table, select, where, args);
                            });
        }

        return queryTask
                .onSuccessTask(
                        task -> {
                            Cursor cursor = task.getResult();
                            List<String> uuids = new ArrayList<>();
                            for (cursor.moveToFirst(); !cursor.isAfterLast(); cursor.moveToNext()) {
                                uuids.add(cursor.getString(0));
                            }
                            cursor.close();

                            // Find objects that match the where clause.
                            final ConstraintMatcher<T> matcher =
                                    queryLogic.createMatcher(query, user);

                            Task<Void> checkedAllObjects = Task.forResult(null);
                            for (final String uuid : uuids) {
                                final Capture<T> object = new Capture<>();

                                checkedAllObjects =
                                        checkedAllObjects
                                                .onSuccessTask(
                                                        (Continuation<Void, Task<T>>)
                                                                task16 -> getPointerAsync(uuid, db))
                                                .onSuccessTask(
                                                        task15 -> {
                                                            object.set(task15.getResult());
                                                            return fetchLocallyAsync(
                                                                    object.get(), db);
                                                        })
                                                .onSuccessTask(
                                                        task14 -> {
                                                            if (!object.get().isDataAvailable()) {
                                                                return Task.forResult(false);
                                                            }
                                                            return matcher.matchesAsync(
                                                                    object.get(), db);
                                                        })
                                                .onSuccess(
                                                        task13 -> {
                                                            if (task13.getResult()) {
                                                                results.add(object.get());
                                                            }
                                                            return null;
                                                        });
                            }

                            return checkedAllObjects;
                        })
                .onSuccessTask(
                        task -> {
                            // Sort by any sort operators.
                            OfflineQueryLogic.sort(results, query);

                            // Apply the skip.
                            List<T> trimmedResults = results;
                            int skip = query.skip();
                            if (!isCount && skip >= 0) {
                                skip = Math.min(query.skip(), trimmedResults.size());
                                trimmedResults =
                                        trimmedResults.subList(skip, trimmedResults.size());
                            }

                            // Trim to the limit.
                            int limit = query.limit();
                            if (!isCount && limit >= 0 && trimmedResults.size() > limit) {
                                trimmedResults = trimmedResults.subList(0, limit);
                            }

                            // Fetch the includes.
                            Task<Void> fetchedIncludesTask = Task.forResult(null);
                            for (final T object : trimmedResults) {
                                fetchedIncludesTask =
                                        fetchedIncludesTask.onSuccessTask(
                                                task12 ->
                                                        OfflineQueryLogic.fetchIncludesAsync(
                                                                OfflineStore.this,
                                                                object,
                                                                query,
                                                                db));
                            }

                            final List<T> finalTrimmedResults = trimmedResults;
                            return fetchedIncludesTask.onSuccess(task1 -> finalTrimmedResults);
                        });
    }

    /**
     * Gets the data for the given object from the offline database. Returns a task that will be
     * completed if data for the object was available. If the object is not in the cache, the task
     * will be faulted, with a CACHE_MISS error.
     *
     * @param object The object to fetch.
     * @param db A database connection to use.
     */
    /* package for OfflineQueryLogic */ <T extends ParseObject> Task<T> fetchLocallyAsync(
            final T object, final ParseSQLiteDatabase db) {
        final TaskCompletionSource<T> tcs = new TaskCompletionSource<>();
        Task<String> uuidTask;

        synchronized (lock) {
            if (fetchedObjects.containsKey(object)) {
                /*
                 * The object has already been fetched from the offline store, so any data that's in there
                 * is already reflected in the in-memory version. There's nothing more to do.
                 */
                //noinspection unchecked
                return (Task<T>) fetchedObjects.get(object);
            }

            /*
             * Put a placeholder so that anyone else who attempts to fetch this object will just wait for
             * this call to finish doing it.
             */
            //noinspection unchecked
            fetchedObjects.put(object, (Task<ParseObject>) tcs.getTask());

            uuidTask = objectToUuidMap.get(object);
        }
        String className = object.getClassName();
        String objectId = object.getObjectId();

        /*
         * If this gets set, then it will contain data from the offline store that needs to be merged
         * into the existing object in memory.
         */
        Task<String> jsonStringTask = Task.forResult(null);

        if (objectId == null) {
            // This Object has never been saved to Parse.
            if (uuidTask == null) {
                /*
                 * This object was not pulled from the data store or previously saved to it, so there's
                 * nothing that can be fetched from it. This isn't an error, because it's really convenient
                 * to try to fetch objects from the offline store just to make sure they are up-to-date, and
                 * we shouldn't force developers to specially handle this case.
                 */
            } else {
                /*
                 * This object is a new ParseObject that is known to the data store, but hasn't been
                 * fetched. The only way this could happen is if the object had previously been stored in
                 * the offline store, then the object was removed from memory (maybe by rebooting), and then
                 * a object with a pointer to it was fetched, so we only created the pointer. We need to
                 * pull the data out of the database using the UUID.
                 */
                final String[] select = {OfflineSQLiteOpenHelper.KEY_JSON};
                final String where = OfflineSQLiteOpenHelper.KEY_UUID + " = ?";
                final Capture<String> uuid = new Capture<>();
                jsonStringTask =
                        uuidTask.onSuccessTask(
                                        task -> {
                                            uuid.set(task.getResult());
                                            String[] args = {uuid.get()};
                                            return db.queryAsync(
                                                    OfflineSQLiteOpenHelper.TABLE_OBJECTS,
                                                    select,
                                                    where,
                                                    args);
                                        })
                                .onSuccess(
                                        task -> {
                                            Cursor cursor = task.getResult();
                                            cursor.moveToFirst();
                                            if (cursor.isAfterLast()) {
                                                cursor.close();
                                                throw new IllegalStateException(
                                                        "Attempted to find non-existent uuid "
                                                                + uuid.get());
                                            }
                                            String json = cursor.getString(0);
                                            cursor.close();

                                            return json;
                                        });
            }
        } else {
            if (uuidTask != null) {
                /*
                 * This object is an existing ParseObject, and we must've already pulled its data out of the
                 * offline store, or else we wouldn't know its UUID. This should never happen.
                 */
                tcs.setError(
                        new IllegalStateException(
                                "This object must have already been "
                                        + "fetched from the local datastore, but isn't marked as fetched."));
                synchronized (lock) {
                    // Forget we even tried to fetch this object, so that retries will actually...
                    // retry.
                    fetchedObjects.remove(object);
                }
                return tcs.getTask();
            }

            /*
             * We've got a pointer to an existing ParseObject, but we've never pulled its data out of the
             * offline store. Since fetching from the server forces a fetch from the offline store, that
             * means this is a pointer. We need to try to find any existing entry for this object in the
             * database.
             */
            String[] select = {OfflineSQLiteOpenHelper.KEY_JSON, OfflineSQLiteOpenHelper.KEY_UUID};
            String where =
                    String.format(
                            "%s = ? AND %s = ?",
                            OfflineSQLiteOpenHelper.KEY_CLASS_NAME,
                            OfflineSQLiteOpenHelper.KEY_OBJECT_ID);
            String[] args = {className, objectId};
            jsonStringTask =
                    db.queryAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, select, where, args)
                            .onSuccess(
                                    task -> {
                                        Cursor cursor = task.getResult();
                                        cursor.moveToFirst();
                                        if (cursor.isAfterLast()) {
                                            /*
                                             * This is a pointer that came from Parse that references an object that has
                                             * never been saved in the offline store before. This just means there's no data
                                             * in the store that needs to be merged into the object.
                                             */
                                            cursor.close();
                                            throw new ParseException(
                                                    ParseException.CACHE_MISS,
                                                    "This object is not available in the offline cache.");
                                        }

                                        // we should fetch its data and record its UUID for future
                                        // reference.
                                        String jsonString = cursor.getString(0);
                                        String newUUID = cursor.getString(1);
                                        cursor.close();

                                        synchronized (lock) {
                                            /*
                                             * It's okay to put this object into the uuid map. No one will try to fetch
                                             * it, because it's already in the fetchedObjects map. And no one will try to
                                             * save to it without fetching it first, so everything should be just fine.
                                             */
                                            objectToUuidMap.put(object, Task.forResult(newUUID));
                                            uuidToObjectMap.put(newUUID, object);
                                        }

                                        return jsonString;
                                    });
        }

        return jsonStringTask
                .onSuccessTask(
                        (Continuation<String, Task<Void>>)
                                task -> {
                                    String jsonString = task.getResult();
                                    if (jsonString == null) {
                                        /*
                                         * This means we tried to fetch an object from the database that was never actually saved
                                         * locally. This probably means that its parent object was saved locally and we just
                                         * created a pointer to this object. This should be considered a cache miss.
                                         */
                                        return Task.forError(
                                                new ParseException(
                                                        ParseException.CACHE_MISS,
                                                        "Attempted to fetch an object offline which was never saved to the offline cache."));
                                    }
                                    final JSONObject json;
                                    try {
                                        /*
                                         * We can assume that whatever is in the database is the last known server state. The only
                                         * things to maintain from the in-memory object are any changes since the object was last
                                         * put in the database.
                                         */
                                        json = new JSONObject(jsonString);
                                    } catch (JSONException e) {
                                        return Task.forError(e);
                                    }

                                    // Fetch all the offline objects before we decode.
                                    final Map<String, Task<ParseObject>> offlineObjects =
                                            new HashMap<>();

                                    (new ParseTraverser() {
                                                @Override
                                                protected boolean visit(Object object1) {
                                                    if (object1 instanceof JSONObject
                                                            && ((JSONObject) object1)
                                                                    .optString("__type")
                                                                    .equals("OfflineObject")) {
                                                        String uuid =
                                                                ((JSONObject) object1)
                                                                        .optString("uuid");
                                                        offlineObjects.put(
                                                                uuid, getPointerAsync(uuid, db));
                                                    }
                                                    return true;
                                                }
                                            })
                                            .setTraverseParseObjects(false)
                                            .setYieldRoot(false)
                                            .traverse(json);

                                    return Task.whenAll(offlineObjects.values())
                                            .onSuccess(
                                                    task1 -> {
                                                        object.mergeREST(
                                                                object.getState(),
                                                                json,
                                                                new OfflineDecoder(offlineObjects));
                                                        return null;
                                                    });
                                })
                .continueWithTask(
                        task -> {
                            if (task.isCancelled()) {
                                tcs.setCancelled();
                            } else if (task.isFaulted()) {
                                tcs.setError(task.getError());
                            } else {
                                tcs.setResult(object);
                            }
                            return tcs.getTask();
                        });
    }

    /**
     * Gets the data for the given object from the offline database. Returns a task that will be
     * completed if data for the object was available. If the object is not in the cache, the task
     * will be faulted, with a CACHE_MISS error.
     *
     * @param object The object to fetch.
     */
    /* package */ <T extends ParseObject> Task<T> fetchLocallyAsync(final T object) {
        return runWithManagedConnection(db -> fetchLocallyAsync(object, db));
    }

    /**
     * Stores a single object in the local database. If the object is a pointer, isn't dirty, and
     * has an objectId already, it may not be saved, since it would provide no useful data.
     *
     * @param object The object to save.
     * @param db A database connection to use.
     */
    private Task<Void> saveLocallyAsync(
            final String key, final ParseObject object, final ParseSQLiteDatabase db) {
        // If this is just a clean, unfetched pointer known to Parse, then there is nothing to save.
        if (object.getObjectId() != null
                && !object.isDataAvailable()
                && !object.hasChanges()
                && !object.hasOutstandingOperations()) {
            return Task.forResult(null);
        }

        final Capture<String> uuidCapture = new Capture<>();

        // Make sure we have a UUID for the object to be saved.
        return getOrCreateUUIDAsync(object, db)
                .onSuccessTask(
                        task -> {
                            String uuid = task.getResult();
                            uuidCapture.set(uuid);
                            return updateDataForObjectAsync(uuid, object, db);
                        })
                .onSuccessTask(
                        task -> {
                            final ContentValues values = new ContentValues();
                            values.put(OfflineSQLiteOpenHelper.KEY_KEY, key);
                            values.put(OfflineSQLiteOpenHelper.KEY_UUID, uuidCapture.get());
                            return db.insertWithOnConflict(
                                    OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES,
                                    values,
                                    SQLiteDatabase.CONFLICT_IGNORE);
                        });
    }

    /**
     * Stores an object (and optionally, every object it points to recursively) in the local
     * database. If any of the objects have not been fetched from Parse, they will not be stored.
     * However, if they have changed data, the data will be retained. To get the objects back later,
     * you can use a ParseQuery with a cache policy that uses the local cache, or you can create an
     * unfetched pointer with ParseObject.createWithoutData() and then call
     * fetchFromLocalDatastore() on it. If you modify the object after saving it locally, such as by
     * fetching it or saving it, those changes will automatically be applied to the cache.
     *
     * <p>Any objects previously stored with the same key will be removed from the local database.
     *
     * @param object Root object
     * @param includeAllChildren {@code true} to recursively save all pointers.
     * @param db DB connection
     * @return A Task that will be resolved when saving is complete
     */
    private Task<Void> saveLocallyAsync(
            final ParseObject object,
            final boolean includeAllChildren,
            final ParseSQLiteDatabase db) {
        final ArrayList<ParseObject> objectsInTree = new ArrayList<>();
        // Fetch all objects locally in case they are being re-added
        if (!includeAllChildren) {
            objectsInTree.add(object);
        } else {
            (new ParseTraverser() {
                        @Override
                        protected boolean visit(Object object) {
                            if (object instanceof ParseObject) {
                                objectsInTree.add((ParseObject) object);
                            }
                            return true;
                        }
                    })
                    .setYieldRoot(true)
                    .setTraverseParseObjects(true)
                    .traverse(object);
        }

        return saveLocallyAsync(object, objectsInTree, db);
    }

    private Task<Void> saveLocallyAsync(
            final ParseObject object, List<ParseObject> children, final ParseSQLiteDatabase db) {
        final List<ParseObject> objects =
                children != null ? new ArrayList<>(children) : new ArrayList<>();
        if (!objects.contains(object)) {
            objects.add(object);
        }

        // Call saveLocallyAsync for each of them individually.
        final List<Task<Void>> tasks = new ArrayList<>();
        for (ParseObject obj : objects) {
            tasks.add(fetchLocallyAsync(obj, db).makeVoid());
        }

        return Task.whenAll(tasks)
                .continueWithTask(task -> objectToUuidMap.get(object))
                .onSuccessTask(
                        task -> {
                            String uuid = task.getResult();
                            if (uuid == null) {
                                // The root object was never stored in the offline store, so nothing
                                // to unpin.
                                return null;
                            }

                            // Delete all objects locally corresponding to the key we're trying to
                            // use in case it was
                            // used before (overwrite)
                            return unpinAsync(uuid, db);
                        })
                .onSuccessTask(task -> getOrCreateUUIDAsync(object, db))
                .onSuccessTask(
                        task -> {
                            String uuid = task.getResult();

                            // Call saveLocallyAsync for each of them individually.
                            final List<Task<Void>> tasks1 = new ArrayList<>();
                            for (ParseObject obj : objects) {
                                tasks1.add(saveLocallyAsync(uuid, obj, db));
                            }

                            return Task.whenAll(tasks1);
                        });
    }

    private Task<Void> unpinAsync(final ParseObject object, final ParseSQLiteDatabase db) {
        Task<String> uuidTask = objectToUuidMap.get(object);
        if (uuidTask == null) {
            // The root object was never stored in the offline store, so nothing to unpin.
            return Task.forResult(null);
        }
        return uuidTask.continueWithTask(
                task -> {
                    final String uuid = task.getResult();
                    if (uuid == null) {
                        // The root object was never stored in the offline store, so nothing to
                        // unpin.
                        return Task.forResult(null);
                    }
                    return unpinAsync(uuid, db);
                });
    }

    private Task<Void> unpinAsync(final String key, final ParseSQLiteDatabase db) {
        final List<String> uuidsToDelete = new LinkedList<>();
        // A continueWithTask that ends with "return task" is essentially a try-finally.
        return Task.forResult((Void) null)
                .continueWithTask(
                        task -> {
                            // Fetch all uuids from Dependencies for key=? grouped by uuid having a
                            // count of 1
                            String sql =
                                    "SELECT "
                                            + OfflineSQLiteOpenHelper.KEY_UUID
                                            + " FROM "
                                            + OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES
                                            + " WHERE "
                                            + OfflineSQLiteOpenHelper.KEY_KEY
                                            + "=? AND "
                                            + OfflineSQLiteOpenHelper.KEY_UUID
                                            + " IN ("
                                            + " SELECT "
                                            + OfflineSQLiteOpenHelper.KEY_UUID
                                            + " FROM "
                                            + OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES
                                            + " GROUP BY "
                                            + OfflineSQLiteOpenHelper.KEY_UUID
                                            + " HAVING COUNT("
                                            + OfflineSQLiteOpenHelper.KEY_UUID
                                            + ")=1"
                                            + ")";
                            String[] args = {key};
                            return db.rawQueryAsync(sql, args);
                        })
                .onSuccessTask(
                        task -> {
                            // DELETE FROM Objects

                            Cursor cursor = task.getResult();
                            while (cursor.moveToNext()) {
                                uuidsToDelete.add(cursor.getString(0));
                            }
                            cursor.close();

                            return deleteObjects(uuidsToDelete, db);
                        })
                .onSuccessTask(
                        task -> {
                            // DELETE FROM Dependencies
                            String where = OfflineSQLiteOpenHelper.KEY_KEY + "=?";
                            String[] args = {key};
                            return db.deleteAsync(
                                    OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES, where, args);
                        })
                .onSuccess(
                        task -> {
                            synchronized (lock) {
                                // Remove uuids from memory
                                for (String uuid : uuidsToDelete) {
                                    ParseObject object = uuidToObjectMap.get(uuid);
                                    if (object != null) {
                                        objectToUuidMap.remove(object);
                                        uuidToObjectMap.remove(uuid);
                                    }
                                }
                            }
                            return null;
                        });
    }

    private Task<Void> deleteObjects(final List<String> uuids, final ParseSQLiteDatabase db) {
        if (uuids.size() <= 0) {
            return Task.forResult(null);
        }

        // SQLite has a max 999 SQL variables in a statement, so we need to split it up into
        // manageable
        // chunks. We can do this because we're already in a transaction.
        if (uuids.size() > MAX_SQL_VARIABLES) {
            return deleteObjects(uuids.subList(0, MAX_SQL_VARIABLES), db)
                    .onSuccessTask(
                            task ->
                                    deleteObjects(
                                            uuids.subList(MAX_SQL_VARIABLES, uuids.size()), db));
        }

        String[] placeholders = new String[uuids.size()];
        Arrays.fill(placeholders, "?");
        String where =
                OfflineSQLiteOpenHelper.KEY_UUID
                        + " IN ("
                        + TextUtils.join(",", placeholders)
                        + ")";
        // dynamic args
        String[] args = uuids.toArray(new String[0]);
        return db.deleteAsync(OfflineSQLiteOpenHelper.TABLE_OBJECTS, where, args);
    }

    /**
     * Takes an object that has been fetched from the database before and updates it with whatever
     * data is in memory. This will only be used when data comes back from the server after a fetch
     * or a save.
     */
    /* package */ Task<Void> updateDataForObjectAsync(final ParseObject object) {
        Task<ParseObject> fetched;
        // Make sure the object is fetched.
        synchronized (lock) {
            fetched = fetchedObjects.get(object);
            if (fetched == null) {
                return Task.forError(
                        new IllegalStateException(
                                "An object cannot be updated if it wasn't fetched."));
            }
        }
        return fetched.continueWithTask(
                task -> {
                    if (task.isFaulted()) {
                        // Catch CACHE_MISS
                        //noinspection ThrowableResultOfMethodCallIgnored
                        if (task.getError() instanceof ParseException
                                && ((ParseException) task.getError()).getCode()
                                        == ParseException.CACHE_MISS) {
                            return Task.forResult(null);
                        }
                        return task.makeVoid();
                    }

                    return helper.getWritableDatabaseAsync()
                            .continueWithTask(
                                    task14 -> {
                                        final ParseSQLiteDatabase db = task14.getResult();
                                        return db.beginTransactionAsync()
                                                .onSuccessTask(
                                                        task13 -> {
                                                            // } finally {
                                                            return updateDataForObjectAsync(
                                                                            object, db)
                                                                    .onSuccessTask(
                                                                            task12 ->
                                                                                    db
                                                                                            .setTransactionSuccessfulAsync())
                                                                    .continueWithTask(
                                                                            task1 -> {
                                                                                db
                                                                                        .endTransactionAsync();
                                                                                db.closeAsync();
                                                                                return task1;
                                                                            });
                                                        });
                                    });
                });
    }

    private Task<Void> updateDataForObjectAsync(
            final ParseObject object, final ParseSQLiteDatabase db) {
        // Make sure the object has a UUID.
        Task<String> uuidTask;
        synchronized (lock) {
            uuidTask = objectToUuidMap.get(object);
            if (uuidTask == null) {
                // It was fetched, but it has no UUID. That must mean it isn't actually in the
                // database.
                return Task.forResult(null);
            }
        }
        return uuidTask.onSuccessTask(
                task -> {
                    String uuid = task.getResult();
                    return updateDataForObjectAsync(uuid, object, db);
                });
    }

    private Task<Void> updateDataForObjectAsync(
            final String uuid, final ParseObject object, final ParseSQLiteDatabase db) {
        // Now actually encode the object as JSON.
        OfflineEncoder encoder = new OfflineEncoder(db);
        final JSONObject json = object.toRest(encoder);

        return encoder.whenFinished()
                .onSuccessTask(
                        task -> {
                            // Put the JSON in the database.
                            String className = object.getClassName();
                            String objectId = object.getObjectId();
                            int isDeletingEventually =
                                    json.getInt(ParseObject.KEY_IS_DELETING_EVENTUALLY);

                            final ContentValues values = new ContentValues();
                            values.put(OfflineSQLiteOpenHelper.KEY_CLASS_NAME, className);
                            values.put(OfflineSQLiteOpenHelper.KEY_JSON, json.toString());
                            if (objectId != null) {
                                values.put(OfflineSQLiteOpenHelper.KEY_OBJECT_ID, objectId);
                            }
                            values.put(
                                    OfflineSQLiteOpenHelper.KEY_IS_DELETING_EVENTUALLY,
                                    isDeletingEventually);
                            String where = OfflineSQLiteOpenHelper.KEY_UUID + " = ?";
                            String[] args = {uuid};
                            return db.updateAsync(
                                            OfflineSQLiteOpenHelper.TABLE_OBJECTS,
                                            values,
                                            where,
                                            args)
                                    .makeVoid();
                        });
    }

    /* package */ Task<Void> deleteDataForObjectAsync(final ParseObject object) {
        return helper.getWritableDatabaseAsync()
                .continueWithTask(
                        task -> {
                            final ParseSQLiteDatabase db = task.getResult();
                            return db.beginTransactionAsync()
                                    .onSuccessTask(
                                            task13 -> {
                                                // } finally {
                                                return deleteDataForObjectAsync(object, db)
                                                        .onSuccessTask(
                                                                task12 ->
                                                                        db
                                                                                .setTransactionSuccessfulAsync())
                                                        .continueWithTask(
                                                                task1 -> {
                                                                    db.endTransactionAsync();
                                                                    db.closeAsync();
                                                                    return task1;
                                                                });
                                            });
                        });
    }

    private Task<Void> deleteDataForObjectAsync(
            final ParseObject object, final ParseSQLiteDatabase db) {
        final Capture<String> uuid = new Capture<>();

        // Make sure the object has a UUID.
        Task<String> uuidTask;
        synchronized (lock) {
            uuidTask = objectToUuidMap.get(object);
            if (uuidTask == null) {
                // It was fetched, but it has no UUID. That must mean it isn't actually in the
                // database.
                return Task.forResult(null);
            }
        }
        uuidTask =
                uuidTask.onSuccessTask(
                        task -> {
                            uuid.set(task.getResult());
                            return task;
                        });

        // If the object was the root of a pin, unpin it.
        Task<Void> unpinTask =
                uuidTask.onSuccessTask(
                                task -> {
                                    // Find all the roots for this object.
                                    String[] select = {OfflineSQLiteOpenHelper.KEY_KEY};
                                    String where = OfflineSQLiteOpenHelper.KEY_UUID + "=?";
                                    String[] args = {uuid.get()};
                                    return db.queryAsync(
                                            OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES,
                                            select,
                                            where,
                                            args);
                                })
                        .onSuccessTask(
                                task -> {
                                    // Try to unpin this object from the pin label if it's a root of
                                    // the ParsePin.
                                    Cursor cursor = task.getResult();
                                    List<String> uuids = new ArrayList<>();
                                    for (cursor.moveToFirst();
                                            !cursor.isAfterLast();
                                            cursor.moveToNext()) {
                                        uuids.add(cursor.getString(0));
                                    }
                                    cursor.close();

                                    List<Task<Void>> tasks = new ArrayList<>();
                                    for (final String uuid1 : uuids) {
                                        Task<Void> unpinTask1 =
                                                getPointerAsync(uuid1, db)
                                                        .onSuccessTask(
                                                                task12 -> {
                                                                    ParsePin pin =
                                                                            (ParsePin)
                                                                                    task12
                                                                                            .getResult();
                                                                    return fetchLocallyAsync(
                                                                            pin, db);
                                                                })
                                                        .continueWithTask(
                                                                task1 -> {
                                                                    ParsePin pin =
                                                                            task1.getResult();

                                                                    List<ParseObject> modified =
                                                                            pin.getObjects();
                                                                    if (modified == null
                                                                            || !modified.contains(
                                                                                    object)) {
                                                                        return task1.makeVoid();
                                                                    }

                                                                    modified.remove(object);
                                                                    if (modified.size() == 0) {
                                                                        return unpinAsync(
                                                                                uuid1, db);
                                                                    }

                                                                    pin.setObjects(modified);
                                                                    return saveLocallyAsync(
                                                                            pin, true, db);
                                                                });
                                        tasks.add(unpinTask1);
                                    }

                                    return Task.whenAll(tasks);
                                });

        // Delete the object from the Local Datastore in case it wasn't the root of a pin.
        return unpinTask
                .onSuccessTask(
                        task -> {
                            String where = OfflineSQLiteOpenHelper.KEY_UUID + "=?";
                            String[] args = {uuid.get()};
                            return db.deleteAsync(
                                    OfflineSQLiteOpenHelper.TABLE_DEPENDENCIES, where, args);
                        })
                .onSuccessTask(
                        task -> {
                            String where = OfflineSQLiteOpenHelper.KEY_UUID + "=?";
                            String[] args = {uuid.get()};
                            return db.deleteAsync(
                                    OfflineSQLiteOpenHelper.TABLE_OBJECTS, where, args);
                        })
                .onSuccessTask(
                        task -> {
                            synchronized (lock) {
                                // Clean up
                                // TODO (grantland): we should probably clean up uuidToObjectMap and
                                // objectToUuidMap, but
                                // getting the uuid requires a task and things might get a little
                                // funky...
                                fetchedObjects.remove(object);
                            }
                            return task;
                        });
    }

    private Task<ParsePin> getParsePin(final String name, ParseSQLiteDatabase db) {
        ParseQuery.State<ParsePin> query =
                new ParseQuery.State.Builder<>(ParsePin.class)
                        .whereEqualTo(ParsePin.KEY_NAME, name)
                        .build();

        /* We need to call directly to the OfflineStore since we don't want/need a user to query for
         * ParsePins
         */
        return findAsync(query, null, null, db)
                .onSuccess(
                        task -> {
                            ParsePin pin = null;
                            if (task.getResult() != null && task.getResult().size() > 0) {
                                pin = task.getResult().get(0);
                            }

                            // TODO (grantland): What do we do if there are more than 1 result?

                            if (pin == null) {
                                pin = ParseObject.create(ParsePin.class);
                                pin.setName(name);
                            }
                            return pin;
                        });
    }

    // region ParsePin

    /* package */ <T extends ParseObject> Task<Void> pinAllObjectsAsync(
            final String name, final List<T> objects, final boolean includeChildren) {
        return runWithManagedTransaction(
                db -> pinAllObjectsAsync(name, objects, includeChildren, db));
    }

    private <T extends ParseObject> Task<Void> pinAllObjectsAsync(
            final String name,
            final List<T> objects,
            final boolean includeChildren,
            final ParseSQLiteDatabase db) {
        if (objects == null || objects.size() == 0) {
            return Task.forResult(null);
        }

        return getParsePin(name, db)
                .onSuccessTask(
                        task -> {
                            ParsePin pin = task.getResult();

                            // TODO (grantland): change to use relations. currently the related PO
                            // are only getting saved
                            // offline as pointers.
                            //        ParseRelation<ParseObject> relation =
                            // pin.getRelation(KEY_OBJECTS);
                            //        relation.add(object);

                            // Hack to store collections in a pin
                            List<ParseObject> modified = pin.getObjects();
                            if (modified == null) {
                                modified = new ArrayList<>(objects);
                            } else {
                                for (ParseObject object : objects) {
                                    if (!modified.contains(object)) {
                                        modified.add(object);
                                    }
                                }
                            }
                            pin.setObjects(modified);

                            if (includeChildren) {
                                return saveLocallyAsync(pin, true, db);
                            }
                            return saveLocallyAsync(pin, pin.getObjects(), db);
                        });
    }

    /* package */ <T extends ParseObject> Task<Void> unpinAllObjectsAsync(
            final String name, final List<T> objects) {
        return runWithManagedTransaction(db -> unpinAllObjectsAsync(name, objects, db));
    }

    private <T extends ParseObject> Task<Void> unpinAllObjectsAsync(
            String name, final List<T> objects, final ParseSQLiteDatabase db) {
        if (objects == null || objects.size() == 0) {
            return Task.forResult(null);
        }

        return getParsePin(name, db)
                .onSuccessTask(
                        task -> {
                            ParsePin pin = task.getResult();

                            // TODO (grantland): change to use relations. currently the related PO
                            // are only getting saved
                            // offline as pointers.
                            //        ParseRelation<ParseObject> relation =
                            // pin.getRelation(KEY_OBJECTS);
                            //        relation.remove(object);

                            // Hack to store collections in a pin
                            List<ParseObject> modified = pin.getObjects();
                            if (modified == null) {
                                // Unpin a pin that doesn't exist. Wat?
                                return Task.forResult(null);
                            }

                            modified.removeAll(objects);
                            if (modified.size() == 0) {
                                return unpinAsync(pin, db);
                            }
                            pin.setObjects(modified);

                            return saveLocallyAsync(pin, true, db);
                        });
    }

    /* package */ Task<Void> unpinAllObjectsAsync(final String name) {
        return runWithManagedTransaction(db -> unpinAllObjectsAsync(name, db));
    }

    private Task<Void> unpinAllObjectsAsync(final String name, final ParseSQLiteDatabase db) {
        return getParsePin(name, db)
                .continueWithTask(
                        task -> {
                            if (task.isFaulted()) {
                                return task.makeVoid();
                            }
                            ParsePin pin = task.getResult();
                            return unpinAsync(pin, db);
                        });
    }

    /* package */ <T extends ParseObject> Task<List<T>> findFromPinAsync(
            final String name, final ParseQuery.State<T> state, final ParseUser user) {
        return runWithManagedConnection(db -> findFromPinAsync(name, state, user, db));
    }

    private <T extends ParseObject> Task<List<T>> findFromPinAsync(
            final String name,
            final ParseQuery.State<T> state,
            final ParseUser user,
            final ParseSQLiteDatabase db) {
        Task<ParsePin> task;
        if (name != null) {
            task = getParsePin(name, db);
        } else {
            task = Task.forResult(null);
        }
        return task.onSuccessTask(
                task1 -> {
                    ParsePin pin = task1.getResult();
                    return findAsync(state, user, pin, false, db);
                });
    }

    /* package */ <T extends ParseObject> Task<Integer> countFromPinAsync(
            final String name, final ParseQuery.State<T> state, final ParseUser user) {
        return runWithManagedConnection(db -> countFromPinAsync(name, state, user, db));
    }

    private <T extends ParseObject> Task<Integer> countFromPinAsync(
            final String name,
            final ParseQuery.State<T> state,
            final ParseUser user,
            final ParseSQLiteDatabase db) {
        Task<ParsePin> task;
        if (name != null) {
            task = getParsePin(name, db);
        } else {
            task = Task.forResult(null);
        }
        return task.onSuccessTask(
                task12 -> {
                    ParsePin pin = task12.getResult();
                    return findAsync(state, user, pin, true, db)
                            .onSuccess(task1 -> task1.getResult().size());
                });
    }

    /**
     * This should be called by the ParseObject constructor notify the store that there is an object
     * with this className and objectId.
     */
    /* package */ void registerNewObject(ParseObject object) {
        synchronized (lock) {
            String objectId = object.getObjectId();
            if (objectId != null) {
                String className = object.getClassName();
                Pair<String, String> classNameAndObjectId = Pair.create(className, objectId);
                classNameAndObjectIdToObjectMap.put(classNameAndObjectId, object);
            }
        }
    }

    // endregion

    // region Single Instance

    /* package */ void unregisterObject(ParseObject object) {
        synchronized (lock) {
            String objectId = object.getObjectId();
            if (objectId != null) {
                classNameAndObjectIdToObjectMap.remove(
                        Pair.create(object.getClassName(), objectId));
            }
        }
    }

    /**
     * This should only ever be called from ParseObject.createWithoutData().
     *
     * @return a pair of ParseObject and Boolean. The ParseObject is the object. The Boolean is true
     *     iff the object was newly created.
     */
    /* package */ ParseObject getObject(String className, String objectId) {
        if (objectId == null) {
            throw new IllegalStateException("objectId cannot be null.");
        }

        Pair<String, String> classNameAndObjectId = Pair.create(className, objectId);
        // This lock should never be held by anyone doing disk or database access.
        synchronized (lock) {
            return classNameAndObjectIdToObjectMap.get(classNameAndObjectId);
        }
    }

    /**
     * When an object is finished saving, it gets an objectId. Then it should call this method to
     * clean up the bookeeping around ids.
     */
    /* package */ void updateObjectId(ParseObject object, String oldObjectId, String newObjectId) {
        if (oldObjectId != null) {
            if (oldObjectId.equals(newObjectId)) {
                return;
            }
            /*
             * Special case for re-saving installation if it was deleted on the server
             * @see ParseInstallation#saveAsync(String, Task)
             */
            if (object instanceof ParseInstallation && newObjectId == null) {
                synchronized (lock) {
                    classNameAndObjectIdToObjectMap.remove(
                            Pair.create(object.getClassName(), oldObjectId));
                }
                return;
            } else {
                throw new RuntimeException("objectIds cannot be changed in offline mode.");
            }
        }

        String className = object.getClassName();
        Pair<String, String> classNameAndNewObjectId = Pair.create(className, newObjectId);

        synchronized (lock) {
            // See if there's already an entry for the new object id.
            ParseObject existing = classNameAndObjectIdToObjectMap.get(classNameAndNewObjectId);
            if (existing != null && existing != object) {
                throw new RuntimeException(
                        "Attempted to change an objectId to one that's "
                                + "already known to the Offline Store.");
            }

            // Okay, all clear to add the new reference.
            classNameAndObjectIdToObjectMap.put(classNameAndNewObjectId, object);
        }
    }

    /** Wraps SQLite operations with a managed SQLite connection. */
    private <T> Task<T> runWithManagedConnection(final SQLiteDatabaseCallable<Task<T>> callable) {
        return helper.getWritableDatabaseAsync()
                .onSuccessTask(
                        task -> {
                            final ParseSQLiteDatabase db = task.getResult();
                            return callable.call(db)
                                    .continueWithTask(
                                            task1 -> {
                                                db.closeAsync();
                                                return task1;
                                            });
                        });
    }

    /** Wraps SQLite operations with a managed SQLite connection and transaction. */
    private Task<Void> runWithManagedTransaction(
            final SQLiteDatabaseCallable<Task<Void>> callable) {
        return helper.getWritableDatabaseAsync()
                .onSuccessTask(
                        task -> {
                            final ParseSQLiteDatabase db = task.getResult();
                            return db.beginTransactionAsync()
                                    .onSuccessTask(
                                            task13 ->
                                                    callable.call(db)
                                                            .onSuccessTask(
                                                                    task12 ->
                                                                            db
                                                                                    .setTransactionSuccessfulAsync())
                                                            .continueWithTask(
                                                                    task1 -> {
                                                                        db.endTransactionAsync();
                                                                        db.closeAsync();
                                                                        return task1;
                                                                    }));
                        });
    }

    // endregion

    /** Clears all in-memory caches so that data must be retrieved from disk. */
    void simulateReboot() {
        synchronized (lock) {
            uuidToObjectMap.clear();
            objectToUuidMap.clear();
            classNameAndObjectIdToObjectMap.clear();
            fetchedObjects.clear();
        }
    }

    /** Clears the database on disk. */
    void clearDatabase(Context context) {
        helper.clearDatabase(context);
    }

    private interface SQLiteDatabaseCallable<T> {
        T call(ParseSQLiteDatabase db);
    }

    /*
     * Methods for testing.
     */

    /**
     * Extends the normal JSON -> ParseObject decoding to also deal with placeholders for new
     * objects that have been saved offline.
     */
    private static class OfflineDecoder extends ParseDecoder {
        // A map of UUID -> Task that will be finished once the given ParseObject is loaded.
        // The Tasks should all be finished before decode is called.
        private final Map<String, Task<ParseObject>> offlineObjects;

        private OfflineDecoder(Map<String, Task<ParseObject>> offlineObjects) {
            this.offlineObjects = offlineObjects;
        }

        @Override
        public Object decode(Object object) {
            // If we see an offline id, make sure to decode it.
            if (object instanceof JSONObject
                    && ((JSONObject) object).optString("__type").equals("OfflineObject")) {
                String uuid = ((JSONObject) object).optString("uuid");
                return offlineObjects.get(uuid).getResult();
            }

            /*
             * Embedded objects can't show up here, because we never stored them that way offline.
             */

            return super.decode(object);
        }
    }

    /**
     * An encoder that can encode objects that are available offline. After using this encoder, you
     * must call whenFinished() and wait for its result to be finished before the results of the
     * encoding will be valid.
     */
    private class OfflineEncoder extends ParseEncoder {
        private final Object tasksLock = new Object();
        private final ParseSQLiteDatabase db;
        private final ArrayList<Task<Void>> tasks = new ArrayList<>();

        /**
         * Creates an encoder.
         *
         * @param db A database connection to use.
         */
        public OfflineEncoder(ParseSQLiteDatabase db) {
            this.db = db;
        }

        /**
         * The results of encoding an object with this encoder will not be valid until the task
         * returned by this method is finished.
         */
        public Task<Void> whenFinished() {
            return Task.whenAll(tasks)
                    .continueWithTask(
                            ignore -> {
                                synchronized (tasksLock) {
                                    // It might be better to return an aggregate error here.
                                    for (Task<Void> task : tasks) {
                                        if (task.isFaulted() || task.isCancelled()) {
                                            return task;
                                        }
                                    }
                                    tasks.clear();
                                    return Task.forResult(null);
                                }
                            });
        }

        /**
         * Implements an encoding strategy for Parse Objects that uses offline ids when necessary.
         */
        @Override
        public JSONObject encodeRelatedObject(ParseObject object) {
            try {
                if (object.getObjectId() != null) {
                    JSONObject result = new JSONObject();
                    result.put("__type", "Pointer");
                    result.put("objectId", object.getObjectId());
                    result.put("className", object.getClassName());
                    return result;
                }

                final JSONObject result = new JSONObject();
                result.put("__type", "OfflineObject");
                synchronized (tasksLock) {
                    tasks.add(
                            getOrCreateUUIDAsync(object, db)
                                    .onSuccess(
                                            task -> {
                                                result.put("uuid", task.getResult());
                                                return null;
                                            }));
                }
                return result;
            } catch (JSONException e) {
                // This can literally never happen.
                throw new RuntimeException(e);
            }
        }
    }
}
